"""esphome session fixtures."""

from __future__ import annotations

import asyncio
from asyncio import Event
from collections.abc import AsyncGenerator, Callable, Coroutine, Generator
from pathlib import Path
from typing import TYPE_CHECKING, Any, Protocol
from unittest.mock import AsyncMock, MagicMock, Mock, patch

from aioesphomeapi import (
    APIClient,
    APIVersion,
    BluetoothProxyFeature,
    DeviceInfo,
    EntityInfo,
    EntityState,
    HomeassistantServiceCall,
    LogLevel,
    ReconnectLogic,
    UserService,
    VoiceAssistantAnnounceFinished,
    VoiceAssistantAudioSettings,
    VoiceAssistantFeature,
)
import pytest
from zeroconf import Zeroconf

from homeassistant.components.esphome import dashboard
from homeassistant.components.esphome.const import (
    CONF_ALLOW_SERVICE_CALLS,
    CONF_BLUETOOTH_MAC_ADDRESS,
    CONF_DEVICE_NAME,
    CONF_NOISE_PSK,
    DEFAULT_NEW_CONFIG_ALLOW_ALLOW_SERVICE_CALLS,
    DOMAIN,
)
from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_PORT
from homeassistant.core import HomeAssistant
from homeassistant.setup import async_setup_component

from . import DASHBOARD_HOST, DASHBOARD_PORT, DASHBOARD_SLUG

from tests.common import MockConfigEntry

if TYPE_CHECKING:
    from aioesphomeapi.api_pb2 import SubscribeLogsResponse


class MockGenericDeviceEntryType(Protocol):
    """Mock ESPHome device entry type."""

    async def __call__(
        self,
        mock_client: APIClient,
        entity_info: list[EntityInfo] | None = ...,
        user_service: list[UserService] | None = ...,
        states: list[EntityState] | None = ...,
        mock_storage: bool = ...,
    ) -> MockConfigEntry:
        """Mock an ESPHome device entry."""


class MockESPHomeDeviceType(Protocol):
    """Mock ESPHome device type."""

    async def __call__(
        self,
        mock_client: APIClient,
        entity_info: list[EntityInfo] | None = ...,
        user_service: list[UserService] | None = ...,
        states: list[EntityState] | None = ...,
        entry: MockConfigEntry | None = ...,
        device_info: dict[str, Any] | None = ...,
        mock_storage: bool = ...,
    ) -> MockESPHomeDevice:
        """Mock an ESPHome device."""


class MockBluetoothEntryType(Protocol):
    """Mock ESPHome bluetooth entry type."""

    async def __call__(
        self,
        bluetooth_proxy_feature_flags: BluetoothProxyFeature,
    ) -> MockESPHomeDevice:
        """Mock an ESPHome bluetooth entry."""


_ONE_SECOND = 16000 * 2  # 16Khz 16-bit


@pytest.fixture(autouse=True)
def mock_bluetooth(enable_bluetooth: None) -> None:
    """Auto mock bluetooth."""


@pytest.fixture(autouse=True)
def esphome_mock_async_zeroconf(mock_async_zeroconf: MagicMock) -> None:
    """Auto mock zeroconf."""


@pytest.fixture(autouse=True)
async def load_homeassistant(hass: HomeAssistant) -> None:
    """Load the homeassistant integration."""
    assert await async_setup_component(hass, "homeassistant", {})


@pytest.fixture(autouse=True)
def mock_tts(mock_tts_cache_dir: Path) -> None:
    """Auto mock the tts cache."""


@pytest.fixture
def mock_config_entry(hass: HomeAssistant) -> MockConfigEntry:
    """Return the default mocked config entry."""
    config_entry = MockConfigEntry(
        title="ESPHome Device",
        entry_id="08d821dc059cf4f645cb024d32c8e708",
        domain=DOMAIN,
        data={
            CONF_HOST: "192.168.1.2",
            CONF_PORT: 6053,
            CONF_PASSWORD: "pwd",
            CONF_NOISE_PSK: "12345678123456781234567812345678",
            CONF_DEVICE_NAME: "test",
        },
        # ESPHome unique ids are lower case
        unique_id="11:22:33:44:55:aa",
    )
    config_entry.add_to_hass(hass)
    return config_entry


class BaseMockReconnectLogic(ReconnectLogic):
    """Mock ReconnectLogic."""

    def stop_callback(self) -> None:
        """Stop the reconnect logic."""
        # For the purposes of testing, we don't want to wait
        # for the reconnect logic to finish trying to connect
        self._cancel_connect("forced disconnect from test")
        self._is_stopped = True

    async def stop(self) -> None:
        """Stop the reconnect logic."""
        self.stop_callback()


@pytest.fixture
def mock_device_info() -> DeviceInfo:
    """Return the default mocked device info."""
    return DeviceInfo(
        uses_password=False,
        name="test",
        legacy_bluetooth_proxy_version=0,
        # ESPHome mac addresses are UPPER case
        mac_address="11:22:33:44:55:AA",
        esphome_version="1.0.0",
    )


@pytest.fixture
async def init_integration(
    hass: HomeAssistant, mock_config_entry: MockConfigEntry
) -> MockConfigEntry:
    """Set up the ESPHome integration for testing."""
    await hass.config_entries.async_setup(mock_config_entry.entry_id)
    await hass.async_block_till_done()

    return mock_config_entry


@pytest.fixture
def mock_client(mock_device_info) -> Generator[APIClient]:
    """Mock APIClient."""
    mock_client = Mock(spec=APIClient)

    def mock_constructor(
        address: str,
        port: int,
        password: str | None,
        *,
        client_info: str = "aioesphomeapi",
        keepalive: float = 15.0,
        zeroconf_instance: Zeroconf = None,
        noise_psk: str | None = None,
        expected_name: str | None = None,
    ):
        """Fake the client constructor."""
        mock_client.host = address
        mock_client.port = port
        mock_client.password = password
        mock_client.zeroconf_instance = zeroconf_instance
        mock_client.noise_psk = noise_psk
        return mock_client

    mock_client.side_effect = mock_constructor
    mock_client.device_info = AsyncMock(return_value=mock_device_info)
    mock_client.connect = AsyncMock()
    mock_client.disconnect = AsyncMock()
    mock_client.subscribe_logs = Mock()
    mock_client.list_entities_services = AsyncMock(return_value=([], []))
    mock_client.address = "127.0.0.1"
    mock_client.api_version = APIVersion(99, 99)

    with (
        patch(
            "homeassistant.components.esphome.manager.ReconnectLogic",
            BaseMockReconnectLogic,
        ),
        patch("homeassistant.components.esphome.APIClient", mock_client),
        patch("homeassistant.components.esphome.config_flow.APIClient", mock_client),
    ):
        yield mock_client


@pytest.fixture
async def mock_dashboard(hass: HomeAssistant) -> AsyncGenerator[dict[str, Any]]:
    """Mock dashboard."""
    data = {"configured": [], "importable": []}
    with patch(
        "esphome_dashboard_api.ESPHomeDashboardAPI.get_devices",
        return_value=data,
    ):
        await dashboard.async_set_dashboard_info(
            hass, DASHBOARD_SLUG, DASHBOARD_HOST, DASHBOARD_PORT
        )
        yield data


class MockESPHomeDevice:
    """Mock an esphome device."""

    def __init__(
        self, entry: MockConfigEntry, client: APIClient, device_info: DeviceInfo
    ) -> None:
        """Init the mock."""
        self.entry = entry
        self.client = client
        self.state_callback: Callable[[EntityState], None]
        self.service_call_callback: Callable[[HomeassistantServiceCall], None]
        self.on_disconnect: Callable[[bool], None]
        self.on_connect: Callable[[bool], None]
        self.on_connect_error: Callable[[Exception], None]
        self.home_assistant_state_subscription_callback: Callable[
            [str, str | None], None
        ]
        self.home_assistant_state_request_callback: Callable[[str, str | None], None]
        self.voice_assistant_handle_start_callback: Callable[
            [str, int, VoiceAssistantAudioSettings, str | None],
            Coroutine[Any, Any, int | None],
        ]
        self.voice_assistant_handle_stop_callback: Callable[
            [bool], Coroutine[Any, Any, None]
        ]
        self.voice_assistant_handle_audio_callback: (
            Callable[
                [bytes],
                Coroutine[Any, Any, None],
            ]
            | None
        )
        self.voice_assistant_handle_announcement_finished_callback: (
            Callable[
                [VoiceAssistantAnnounceFinished],
                Coroutine[Any, Any, None],
            ]
            | None
        )
        self.on_log_message: Callable[[SubscribeLogsResponse], None]
        self.device_info = device_info
        self.current_log_level = LogLevel.LOG_LEVEL_NONE

    def set_state_callback(self, state_callback: Callable[[EntityState], None]) -> None:
        """Set the state callback."""
        self.state_callback = state_callback

    def set_service_call_callback(
        self, callback: Callable[[HomeassistantServiceCall], None]
    ) -> None:
        """Set the service call callback."""
        self.service_call_callback = callback

    def mock_service_call(self, service_call: HomeassistantServiceCall) -> None:
        """Mock a service call."""
        self.service_call_callback(service_call)

    def set_state(self, state: EntityState) -> None:
        """Mock setting state."""
        self.state_callback(state)

    def set_on_disconnect(self, on_disconnect: Callable[[bool], None]) -> None:
        """Set the disconnect callback."""
        self.on_disconnect = on_disconnect

    async def mock_disconnect(self, expected_disconnect: bool) -> None:
        """Mock disconnecting."""
        await self.on_disconnect(expected_disconnect)

    def set_on_log_message(
        self, on_log_message: Callable[[SubscribeLogsResponse], None]
    ) -> None:
        """Set the log message callback."""
        self.on_log_message = on_log_message

    def mock_on_log_message(self, log_message: SubscribeLogsResponse) -> None:
        """Mock on log message."""
        self.on_log_message(log_message)

    def set_on_connect(self, on_connect: Callable[[], None]) -> None:
        """Set the connect callback."""
        self.on_connect = on_connect

    def set_on_connect_error(
        self, on_connect_error: Callable[[Exception], None]
    ) -> None:
        """Set the connect error callback."""
        self.on_connect_error = on_connect_error

    async def mock_connect(self) -> None:
        """Mock connecting."""
        await self.on_connect()

    async def mock_connect_error(self, exc: Exception) -> None:
        """Mock connect error."""
        await self.on_connect_error(exc)

    def set_home_assistant_state_subscription_callback(
        self,
        on_state_sub: Callable[[str, str | None], None],
        on_state_request: Callable[[str, str | None], None],
    ) -> None:
        """Set the state call callback."""
        self.home_assistant_state_subscription_callback = on_state_sub
        self.home_assistant_state_request_callback = on_state_request

    def mock_home_assistant_state_subscription(
        self, entity_id: str, attribute: str | None
    ) -> None:
        """Mock a state subscription."""
        self.home_assistant_state_subscription_callback(entity_id, attribute)

    def mock_home_assistant_state_request(
        self, entity_id: str, attribute: str | None
    ) -> None:
        """Mock a state request."""
        self.home_assistant_state_request_callback(entity_id, attribute)

    def set_subscribe_voice_assistant_callbacks(
        self,
        handle_start: Callable[
            [str, int, VoiceAssistantAudioSettings, str | None],
            Coroutine[Any, Any, int | None],
        ],
        handle_stop: Callable[[bool], Coroutine[Any, Any, None]],
        handle_audio: (
            Callable[
                [bytes],
                Coroutine[Any, Any, None],
            ]
            | None
        ) = None,
        handle_announcement_finished: (
            Callable[
                [VoiceAssistantAnnounceFinished],
                Coroutine[Any, Any, None],
            ]
            | None
        ) = None,
    ) -> None:
        """Set the voice assistant subscription callbacks."""
        self.voice_assistant_handle_start_callback = handle_start
        self.voice_assistant_handle_stop_callback = handle_stop
        self.voice_assistant_handle_audio_callback = handle_audio
        self.voice_assistant_handle_announcement_finished_callback = (
            handle_announcement_finished
        )

    async def mock_voice_assistant_handle_start(
        self,
        conversation_id: str,
        flags: int,
        settings: VoiceAssistantAudioSettings,
        wake_word_phrase: str | None,
    ) -> int | None:
        """Mock voice assistant handle start."""
        return await self.voice_assistant_handle_start_callback(
            conversation_id, flags, settings, wake_word_phrase
        )

    async def mock_voice_assistant_handle_stop(self, abort: bool) -> None:
        """Mock voice assistant handle stop."""
        await self.voice_assistant_handle_stop_callback(abort)

    async def mock_voice_assistant_handle_audio(self, audio: bytes) -> None:
        """Mock voice assistant handle audio."""
        assert self.voice_assistant_handle_audio_callback is not None
        await self.voice_assistant_handle_audio_callback(audio)

    async def mock_voice_assistant_handle_announcement_finished(
        self, finished: VoiceAssistantAnnounceFinished
    ) -> None:
        """Mock voice assistant handle announcement finished."""
        assert self.voice_assistant_handle_announcement_finished_callback is not None
        await self.voice_assistant_handle_announcement_finished_callback(finished)


async def _mock_generic_device_entry(
    hass: HomeAssistant,
    mock_client: APIClient,
    mock_device_info: dict[str, Any],
    mock_list_entities_services: tuple[list[EntityInfo], list[UserService]],
    states: list[EntityState],
    entry: MockConfigEntry | None = None,
    hass_storage: dict[str, Any] | None = None,
) -> MockESPHomeDevice:
    if not entry:
        entry = MockConfigEntry(
            domain=DOMAIN,
            data={
                CONF_HOST: "test.local",
                CONF_PORT: 6053,
                CONF_PASSWORD: "",
            },
            options={
                CONF_ALLOW_SERVICE_CALLS: DEFAULT_NEW_CONFIG_ALLOW_ALLOW_SERVICE_CALLS
            },
        )
        entry.add_to_hass(hass)

    default_device_info = {
        "name": "test",
        "friendly_name": "Test",
        "esphome_version": "1.0.0",
        "mac_address": "11:22:33:44:55:AA",
    }
    device_info = DeviceInfo(**(default_device_info | mock_device_info))

    if hass_storage:
        storage_key = f"{DOMAIN}.{entry.entry_id}"
        hass_storage[storage_key] = {
            "version": 1,
            "minor_version": 1,
            "key": storage_key,
            "data": {
                "device_info": device_info.to_dict(),
            },
        }

    mock_device = MockESPHomeDevice(entry, mock_client, device_info)

    def _subscribe_states(callback: Callable[[EntityState], None]) -> None:
        """Subscribe to state."""
        mock_device.set_state_callback(callback)
        for state in states:
            callback(state)

    def _subscribe_service_calls(
        callback: Callable[[HomeassistantServiceCall], None],
    ) -> None:
        """Subscribe to service calls."""
        mock_device.set_service_call_callback(callback)

    def _subscribe_home_assistant_states(
        on_state_sub: Callable[[str, str | None], None],
        on_state_request: Callable[[str, str | None], None],
    ) -> None:
        """Subscribe to home assistant states."""
        mock_device.set_home_assistant_state_subscription_callback(
            on_state_sub, on_state_request
        )

    def _subscribe_logs(
        on_log_message: Callable[[SubscribeLogsResponse], None], log_level: LogLevel
    ) -> Callable[[], None]:
        """Subscribe to log messages."""
        mock_device.set_on_log_message(on_log_message)
        mock_device.current_log_level = log_level
        return lambda: None

    def _subscribe_voice_assistant(
        *,
        handle_start: Callable[
            [str, int, VoiceAssistantAudioSettings, str | None],
            Coroutine[Any, Any, int | None],
        ],
        handle_stop: Callable[[bool], Coroutine[Any, Any, None]],
        handle_audio: (
            Callable[
                [bytes],
                Coroutine[Any, Any, None],
            ]
            | None
        ) = None,
        handle_announcement_finished: (
            Callable[
                [VoiceAssistantAnnounceFinished],
                Coroutine[Any, Any, None],
            ]
            | None
        ) = None,
    ) -> Callable[[], None]:
        """Subscribe to voice assistant."""
        mock_device.set_subscribe_voice_assistant_callbacks(
            handle_start, handle_stop, handle_audio, handle_announcement_finished
        )

        def unsub():
            pass

        return unsub

    mock_client.device_info = AsyncMock(return_value=mock_device.device_info)
    mock_client.subscribe_voice_assistant = _subscribe_voice_assistant
    mock_client.list_entities_services = AsyncMock(
        return_value=mock_list_entities_services
    )
    mock_client.subscribe_states = _subscribe_states
    mock_client.subscribe_service_calls = _subscribe_service_calls
    mock_client.subscribe_home_assistant_states = _subscribe_home_assistant_states
    mock_client.subscribe_logs = _subscribe_logs

    try_connect_done = Event()

    class MockReconnectLogic(BaseMockReconnectLogic):
        """Mock ReconnectLogic."""

        def __init__(self, *args: Any, **kwargs: Any) -> None:
            """Init the mock."""
            super().__init__(*args, **kwargs)
            mock_device.set_on_disconnect(kwargs["on_disconnect"])
            mock_device.set_on_connect(kwargs["on_connect"])
            mock_device.set_on_connect_error(kwargs["on_connect_error"])
            self._try_connect = self.mock_try_connect

        async def mock_try_connect(self):
            """Set an event when ReconnectLogic._try_connect has been awaited."""
            result = await super()._try_connect()
            try_connect_done.set()
            return result

        def stop_callback(self) -> None:
            """Stop the reconnect logic."""
            # For the purposes of testing, we don't want to wait
            # for the reconnect logic to finish trying to connect
            self._cancel_connect("forced disconnect from test")
            self._is_stopped = True

    with patch(
        "homeassistant.components.esphome.manager.ReconnectLogic", MockReconnectLogic
    ):
        assert await hass.config_entries.async_setup(entry.entry_id)
        async with asyncio.timeout(2):
            await try_connect_done.wait()

    await hass.async_block_till_done()
    return mock_device


@pytest.fixture
async def mock_voice_assistant_entry(
    hass: HomeAssistant,
    mock_client: APIClient,
):
    """Set up an ESPHome entry with voice assistant."""

    async def _mock_voice_assistant_entry(
        voice_assistant_feature_flags: VoiceAssistantFeature,
    ) -> MockConfigEntry:
        return (
            await _mock_generic_device_entry(
                hass,
                mock_client,
                {"voice_assistant_feature_flags": voice_assistant_feature_flags},
                ([], []),
                [],
            )
        ).entry

    return _mock_voice_assistant_entry


@pytest.fixture
async def mock_voice_assistant_v1_entry(mock_voice_assistant_entry) -> MockConfigEntry:
    """Set up an ESPHome entry with voice assistant."""
    return await mock_voice_assistant_entry(
        voice_assistant_feature_flags=VoiceAssistantFeature.VOICE_ASSISTANT
    )


@pytest.fixture
async def mock_voice_assistant_v2_entry(mock_voice_assistant_entry) -> MockConfigEntry:
    """Set up an ESPHome entry with voice assistant."""
    return await mock_voice_assistant_entry(
        voice_assistant_feature_flags=VoiceAssistantFeature.VOICE_ASSISTANT
        | VoiceAssistantFeature.SPEAKER
    )


@pytest.fixture
async def mock_voice_assistant_api_entry(mock_voice_assistant_entry) -> MockConfigEntry:
    """Set up an ESPHome entry with voice assistant."""
    return await mock_voice_assistant_entry(
        voice_assistant_feature_flags=VoiceAssistantFeature.VOICE_ASSISTANT
        | VoiceAssistantFeature.SPEAKER
        | VoiceAssistantFeature.API_AUDIO
    )


@pytest.fixture
async def mock_bluetooth_entry(
    hass: HomeAssistant,
    mock_client: APIClient,
) -> MockBluetoothEntryType:
    """Set up an ESPHome entry with bluetooth."""

    async def _mock_bluetooth_entry(
        bluetooth_proxy_feature_flags: BluetoothProxyFeature,
    ) -> MockESPHomeDevice:
        entry = MockConfigEntry(
            domain=DOMAIN,
            data={
                CONF_HOST: "test.local",
                CONF_PORT: 6053,
                CONF_PASSWORD: "",
                CONF_BLUETOOTH_MAC_ADDRESS: "AA:BB:CC:DD:EE:FC",
            },
            options={
                CONF_ALLOW_SERVICE_CALLS: DEFAULT_NEW_CONFIG_ALLOW_ALLOW_SERVICE_CALLS
            },
        )
        entry.add_to_hass(hass)
        return await _mock_generic_device_entry(
            hass,
            mock_client,
            {
                "bluetooth_mac_address": "AA:BB:CC:DD:EE:FC",
                "bluetooth_proxy_feature_flags": bluetooth_proxy_feature_flags,
            },
            ([], []),
            [],
            entry=entry,
        )

    return _mock_bluetooth_entry


@pytest.fixture
async def mock_bluetooth_entry_with_raw_adv(
    mock_bluetooth_entry: MockBluetoothEntryType,
) -> MockESPHomeDevice:
    """Set up an ESPHome entry with bluetooth and raw advertisements."""
    return await mock_bluetooth_entry(
        bluetooth_proxy_feature_flags=BluetoothProxyFeature.PASSIVE_SCAN
        | BluetoothProxyFeature.ACTIVE_CONNECTIONS
        | BluetoothProxyFeature.REMOTE_CACHING
        | BluetoothProxyFeature.PAIRING
        | BluetoothProxyFeature.CACHE_CLEARING
        | BluetoothProxyFeature.RAW_ADVERTISEMENTS
    )


@pytest.fixture
async def mock_bluetooth_entry_with_legacy_adv(
    mock_bluetooth_entry: MockBluetoothEntryType,
) -> MockESPHomeDevice:
    """Set up an ESPHome entry with bluetooth with legacy advertisements."""
    return await mock_bluetooth_entry(
        bluetooth_proxy_feature_flags=BluetoothProxyFeature.PASSIVE_SCAN
        | BluetoothProxyFeature.ACTIVE_CONNECTIONS
        | BluetoothProxyFeature.REMOTE_CACHING
        | BluetoothProxyFeature.PAIRING
        | BluetoothProxyFeature.CACHE_CLEARING
    )


@pytest.fixture
async def mock_generic_device_entry(
    hass: HomeAssistant,
    hass_storage: dict[str, Any],
) -> MockGenericDeviceEntryType:
    """Set up an ESPHome entry and return the MockConfigEntry."""

    async def _mock_device_entry(
        mock_client: APIClient,
        entity_info: list[EntityInfo] | None = None,
        user_service: list[UserService] | None = None,
        states: list[EntityState] | None = None,
        mock_storage: bool = False,
    ) -> MockConfigEntry:
        return (
            await _mock_generic_device_entry(
                hass,
                mock_client,
                {},
                (entity_info or [], user_service or []),
                states or [],
                None,
                hass_storage if mock_storage else None,
            )
        ).entry

    return _mock_device_entry


@pytest.fixture
async def mock_esphome_device(
    hass: HomeAssistant,
    hass_storage: dict[str, Any],
) -> MockESPHomeDeviceType:
    """Set up an ESPHome entry and return the MockESPHomeDevice."""

    async def _mock_device(
        mock_client: APIClient,
        entity_info: list[EntityInfo] | None = None,
        user_service: list[UserService] | None = None,
        states: list[EntityState] | None = None,
        entry: MockConfigEntry | None = None,
        device_info: dict[str, Any] | None = None,
        mock_storage: bool = False,
    ) -> MockESPHomeDevice:
        return await _mock_generic_device_entry(
            hass,
            mock_client,
            device_info or {},
            (entity_info or [], user_service or []),
            states or [],
            entry,
            hass_storage if mock_storage else None,
        )

    return _mock_device
